● Zion Boggan
repos/Stepping App/apps/web/src/app/city/[slug]/page.tsx
zionboggan.com ↗
288 lines · tsx
History for this file →
1
import Link from "next/link";
2
import { SiteFooter } from "@/components/site-footer";
3
import { notFound } from "next/navigation";
4
import { revalidatePath } from "next/cache";
5
import { EVENT_TYPE } from "@stepping/types";
6
import { serverCaller } from "@/lib/trpc/server";
7
import { cityTimeZone, fmtEventTime } from "@/lib/format";
8
 
9
export const dynamic = "force-dynamic";
10
 
11
const TYPE_LABEL: Record<string, string> = {
12
  set: "Set",
13
  weekender: "Weekender",
14
  class: "Class",
15
  workshop: "Workshop",
16
  competition: "Competition",
17
  special: "Special event",
18
  social: "Social",
19
};
20
 
21
const MODULES = [
22
  { seg: "floor", label: "The Floor", note: "Groups, DJs, promoters, instructors, classes" },
23
  { seg: "videos", label: "The Tape", note: "Sets and footwork on camera" },
24
  { seg: "road", label: "The Road", note: "Host hotels, event centers, food, the city guide" },
25
  { seg: "live", label: "Live", note: "Watch a set live, or go live" },
26
];
27
 
28
 
29
export default async function CityPage({
30
  params,
31
}: {
32
  params: Promise<{ slug: string }>;
33
}) {
34
  const { slug } = await params;
35
  const caller = serverCaller();
36
  const city = await caller.city.bySlug({ slug });
37
  if (!city) notFound();
38
 
39
  const tz = cityTimeZone(city.state);
40
 
41
  const events = await caller.event.list({ cityId: city.id });
42
  const citySponsors = await caller.sponsor.listActive({ cityId: city.id });
43
  const liveNow = events.filter((e) => e.isLive);
44
  const promotions = events.filter((e) => e.isPromoted && !e.isLive);
45
  const specials = events.filter((e) => e.type === "special");
46
  const sets = events.filter((e) => !e.isLive);
47
 
48
  async function addEvent(formData: FormData) {
49
    "use server";
50
    const title = String(formData.get("title") ?? "").trim();
51
    const type = String(formData.get("type") ?? "set");
52
    const description = String(formData.get("description") ?? "").trim();
53
    const startsAtRaw = String(formData.get("startsAt") ?? "");
54
    const isPromoted = formData.get("isPromoted") != null;
55
    if (!title) return;
56
    await serverCaller().event.create({
57
      cityId: city!.id,
58
      title,
59
      type: type as (typeof EVENT_TYPE)[number],
60
      description: description || undefined,
61
      startsAt: startsAtRaw ? new Date(startsAtRaw) : undefined,
62
      isPromoted,
63
    });
64
    revalidatePath(`/city/${slug}`);
65
  }
66
 
67
  return (
68
    <main className="mx-auto w-full max-w-3xl px-5 py-16 sm:px-8">
69
      <Link href="/" className="kicker hover:text-gold">
70
        ← Chicago Stepping
71
      </Link>
72
 
73
      <p className="kicker mt-10 text-ash">
74
        {city.metroLabel} · {city.lat?.toFixed(3)}, {city.lng?.toFixed(3)}
75
      </p>
76
      <h1 className="display mt-3 text-7xl sm:text-8xl">{city.name}</h1>
77
      <p className="serif mt-5 max-w-xl text-xl text-ash">
78
        The sets, the people, the music, and the road in. Everything Chicago
79
        steppin&apos; in {city.name}, in one place.
80
      </p>
81
 
82
      {}
83
      {liveNow.length > 0 ? (
84
        <Link
85
          href={`/city/${slug}/live`}
86
          className="mt-10 flex items-center justify-between border border-gold bg-gold/10 px-5 py-4 hover:bg-gold/20"
87
        >
88
          <span className="kicker flex items-center gap-3 text-gold">
89
            <span className="inline-block h-2.5 w-2.5 animate-pulse rounded-full bg-gold" />
90
            Live now · {liveNow.length} streaming
91
          </span>
92
          <span className="kicker text-gold">Watch →</span>
93
        </Link>
94
      ) : null}
95
 
96
      {}
97
      <nav className="mt-10 grid gap-px sm:grid-cols-2">
98
        {MODULES.map((m) => (
99
          <Link
100
            key={m.seg}
101
            href={`/city/${slug}/${m.seg}`}
102
            className="group border-t border-[var(--line)] py-6 pr-6"
103
          >
104
            <div className="flex items-center justify-between">
105
              <h2 className="display text-3xl group-hover:text-gold">{m.label}</h2>
106
              <span className="kicker text-gold opacity-0 group-hover:opacity-100">
107
                →
108
              </span>
109
            </div>
110
            <p className="serif mt-1 text-lg text-ash">{m.note}</p>
111
          </Link>
112
        ))}
113
      </nav>
114
 
115
      {}
116
      {citySponsors.length > 0 ? (
117
        <div className="mt-10 border-t border-[var(--line)] pt-5">
118
          <p className="kicker text-ash">Sponsored in {city.name}</p>
119
          <div className="mt-3 flex flex-wrap gap-x-8 gap-y-3">
120
            {citySponsors.map((s) => {
121
              const inner = (
122
                <span className="group inline-flex items-baseline gap-2">
123
                  <span className="display text-2xl text-bone group-hover:text-gold">
124
                    {s.name}
125
                  </span>
126
                  {s.blurb ? (
127
                    <span className="serif text-lg text-ash">{s.blurb}</span>
128
                  ) : null}
129
                </span>
130
              );
131
              return s.linkUrl ? (
132
                <a key={s.id} href={s.linkUrl} target="_blank" rel="noopener noreferrer nofollow">
133
                  {inner}
134
                </a>
135
              ) : (
136
                <span key={s.id}>{inner}</span>
137
              );
138
            })}
139
          </div>
140
        </div>
141
      ) : null}
142
 
143
      {}
144
      {promotions.length > 0 ? (
145
        <section className="mt-16 border-t border-[var(--line)] pt-10">
146
          <div className="flex items-end justify-between">
147
            <h2 className="display text-3xl">Promotions</h2>
148
            <span className="kicker text-ash">Featured</span>
149
          </div>
150
          <ul className="mt-6 grid gap-px sm:grid-cols-2">
151
            {promotions.map((e) => (
152
              <li key={e.id} className="border-t border-gold/40 py-5 pr-5">
153
                <Link href={`/city/${slug}/event/${e.id}`} className="group block">
154
                  <span className="kicker text-gold">
155
                    {(TYPE_LABEL[e.type] ?? e.type).toUpperCase()} · {fmtEventTime(e.startsAt, tz)}
156
                  </span>
157
                  <p className="display mt-1 text-2xl text-bone group-hover:text-gold">
158
                    {e.title}
159
                  </p>
160
                </Link>
161
              </li>
162
            ))}
163
          </ul>
164
        </section>
165
      ) : null}
166
 
167
      {}
168
      {specials.length > 0 ? (
169
        <section className="mt-16 border-t border-[var(--line)] pt-10">
170
          <div className="flex items-end justify-between">
171
            <h2 className="display text-3xl">Special Events</h2>
172
            <span className="kicker text-ash">Causes &amp; specials</span>
173
          </div>
174
          <ul className="mt-6">
175
            {specials.map((e) => (
176
              <li
177
                key={e.id}
178
                className="flex items-center gap-5 border-b border-[var(--line)] py-6"
179
              >
180
                <div className="flex-1">
181
                  <Link
182
                    href={`/city/${slug}/event/${e.id}`}
183
                    className="display text-2xl text-bone hover:text-gold sm:text-3xl"
184
                  >
185
                    {e.title}
186
                  </Link>
187
                  <p className="kicker mt-1 text-ash">{fmtEventTime(e.startsAt, tz)}</p>
188
                </div>
189
                <span className="kicker border border-[var(--line)] px-3 py-1">
190
                  SPECIAL
191
                </span>
192
              </li>
193
            ))}
194
          </ul>
195
        </section>
196
      ) : null}
197
 
198
      {}
199
      <section className="mt-16 border-t border-[var(--line)] pt-10">
200
        <div className="flex items-end justify-between">
201
          <h2 className="display text-3xl">Post a set</h2>
202
          <span className="kicker">Anyone can add to the city</span>
203
        </div>
204
        <form action={addEvent} className="mt-6 flex flex-col gap-3">
205
          <input
206
            name="title"
207
            required
208
            placeholder="Event name (e.g. Saturday Night Set)"
209
            className="serif border-b border-[var(--line)] bg-transparent py-3 text-2xl text-bone outline-none placeholder:text-ash focus:border-gold"
210
          />
211
          <input
212
            name="description"
213
            placeholder="Add details, dress code, or a promo line (optional)"
214
            className="serif border-b border-[var(--line)] bg-transparent py-2 text-lg text-bone outline-none placeholder:text-ash focus:border-gold"
215
          />
216
          <div className="mt-2 flex flex-wrap items-center gap-4">
217
            <select
218
              name="type"
219
              className="kicker border border-[var(--line)] bg-stage px-4 py-3 text-bone outline-none focus:border-gold"
220
            >
221
              {EVENT_TYPE.map((t) => (
222
                <option key={t} value={t} className="bg-stage">
223
                  {(TYPE_LABEL[t] ?? t).toUpperCase()}
224
                </option>
225
              ))}
226
            </select>
227
            <input
228
              type="datetime-local"
229
              name="startsAt"
230
              className="kicker border border-[var(--line)] bg-stage px-4 py-3 text-bone outline-none focus:border-gold"
231
            />
232
            <label className="kicker flex items-center gap-2 text-ash">
233
              <input type="checkbox" name="isPromoted" className="h-4 w-4 accent-gold" />
234
              Promote
235
            </label>
236
            <button
237
              type="submit"
238
              className="kicker bg-gold px-6 py-3 text-stage hover:bg-gold-bright"
239
            >
240
              Post event
241
            </button>
242
          </div>
243
        </form>
244
      </section>
245
 
246
      {}
247
      <section className="mt-16 border-t border-[var(--line)] pt-10">
248
        <div className="flex items-end justify-between">
249
          <h2 className="display text-3xl">The Sets</h2>
250
          <span className="kicker text-ash">
251
            {String(sets.length).padStart(2, "0")} listed
252
          </span>
253
        </div>
254
        {sets.length === 0 ? (
255
          <p className="serif mt-8 text-xl text-ash">
256
            No sets yet. Post the first one above.
257
          </p>
258
        ) : (
259
          <ul className="mt-6">
260
            {sets.map((e, i) => (
261
              <li
262
                key={e.id}
263
                className="flex items-center gap-5 border-b border-[var(--line)] py-6"
264
              >
265
                <span className="kicker w-8 text-gold">
266
                  {String(i + 1).padStart(2, "0")}
267
                </span>
268
                <div className="flex-1">
269
                  <Link
270
                    href={`/city/${slug}/event/${e.id}`}
271
                    className="display text-2xl text-bone hover:text-gold sm:text-3xl"
272
                  >
273
                    {e.title}
274
                  </Link>
275
                  <p className="kicker mt-1 text-ash">{fmtEventTime(e.startsAt, tz)}</p>
276
                </div>
277
                <span className="kicker border border-[var(--line)] px-3 py-1">
278
                  {(TYPE_LABEL[e.type] ?? e.type).toUpperCase()}
279
                </span>
280
              </li>
281
            ))}
282
          </ul>
283
        )}
284
      </section>
285
      <SiteFooter />
286
    </main>
287
  );
288
}